Updating dependencies on a live Django production server requires discipline. A single broken transitive dependency can take down your entire blog. This guide walks through every single step — from opening a safe SSH session to rolling back if something goes wrong — in the exact order you should execute them.

Before you start: Never run pip install --upgrade blindly across all packages on a live server. Follow each phase in order and do not skip the validation checkpoints.


Step 1: Open a persistent SSH session with tmux


A dropped SSH connection mid-upgrade can leave your virtualenv in a broken half-updated state. tmux keeps the process alive even if the connection drops.


# Install tmux if not already present
which tmux
sudo apt update && sudo apt install -y tmux
 
# Start a named session
tmux new-session -s django_upgrade
 
# If you get disconnected, reattach with:
tmux attach-session -t django_upgrade
 
# Confirm session is running
tmux ls



Step 2: Capture system state baseline


Before touching anything, record what is currently running. This gives you a reference point and confirms the server is healthy before you begin.


# Check disk space — need at least 2 GB free
df -h
 
# Check memory
free -m
 
# Confirm gunicorn and nginx are running
sudo systemctl status nginx gunicorn
 
# Note active gunicorn worker PIDs
ps aux | grep gunicorn
 
# Record current Python and Django versions
python3 --version
python3 -c "import django; print(django.__version__)"
 
# Note OS version
cat /etc/os-release | grep PRETTY_NAME



Step 3: Activate the correct virtualenv


All pip operations must happen inside the project's virtualenv. Never run pip as root or against the system Python. If you are unsure which venv your Django project uses, check the gunicorn service file first.


# Find which venv gunicorn is actually using
sudo cat /etc/systemd/system/gunicorn.service
# Look at the ExecStart= line — it points to the exact Python binary
 
# Or check the running process directly
ps aux | grep gunicorn
# The path shown (e.g. /home/deploy/myproject/venv/bin/python) is your venv
 
# Activate the correct venv using its full path
source /full/confirmed/path/to/venv/bin/activate
 
# VERIFY you are inside the correct venv — this is critical
which python    # must show path inside venv, not /usr/bin/python
which pip       # must show path inside venv, not /usr/bin/pip
echo $VIRTUAL_ENV   # must be non-empty
 
# If you activated the wrong venv, deactivate it first
deactivate
# Then activate the correct one
source /correct/path/to/venv/bin/activate



Step 4: Freeze current dependencies as a backup


This is your rollback target. Save the exact pinned versions of everything currently installed before making any changes.


# Navigate to your project root
cd /path/to/your/django/project
 
# Freeze the exact current state with a timestamp
pip freeze > requirements.backup.$(date +%Y-%m-%d_%H%M).txt
 
# Verify the file is non-empty
wc -l requirements.backup.*.txt
cat requirements.backup.*.txt



Step 5: Take a VPS snapshot and database backup


Take a VPS snapshot from your provider's control panel (Hetzner, DigitalOcean, Vultr) before running any commands. This is your nuclear escape hatch — it restores the entire server in under 5 minutes regardless of what goes wrong.


# Create backup directory
mkdir -p ~/backups
 
# PostgreSQL — compressed custom format (fastest restore)
pg_dump -U your_db_user -h localhost -Fc your_db_name \
  > ~/backups/db_$(date +%Y-%m-%d_%H%M).dump
 
# PostgreSQL — plain SQL as human-readable backup
pg_dump -U your_db_user -h localhost your_db_name \
  > ~/backups/db_$(date +%Y-%m-%d_%H%M).sql
 
# SQLite (if applicable)
cp /path/to/db.sqlite3 ~/backups/db_$(date +%Y-%m-%d_%H%M).sqlite3
 
# Verify backups are non-zero size
ls -lh ~/backups/



Step 6: Upgrade pip, setuptools, and wheel


Old pip versions misread PEP 517/518 build metadata and produce incorrect dependency resolution. Upgrade the toolchain itself before touching any project packages.


# Upgrade pip toolchain first
pip install --upgrade pip setuptools wheel
 
# Confirm new versions
pip --version
python -c "import setuptools; print(setuptools.__version__)"



Step 7: Full dependency audit — see everything that is outdated


Before updating a single package, get a complete picture of what is outdated and by how much. Install the audit tools and save the output for reference.


# Install audit tools
pip install pip-review pip-audit
 
# Formatted columns view — current version vs latest
pip list --outdated --format=columns
 
# Machine-readable JSON
pip list --outdated --format=json | python3 -m json.tool
 
# pip-review summary
pip-review --local
 
# Save the audit to a file for reference during the upgrade
pip list --outdated --format=columns > ~/upgrade_plan_$(date +%Y-%m-%d).txt
cat ~/upgrade_plan_*.txt



Step 8: CVE security scan before updating


Scan the current environment for known vulnerabilities before making any changes. This establishes a baseline and identifies which packages are security-critical to update first.


# pip-audit: scans against the OSV and PyPI advisory database
pip-audit
 
# Also scan from requirements file
pip-audit -r requirements.txt
 
# Save the pre-update scan as JSON for your records
pip-audit --format json > ~/cve_pre_$(date +%Y-%m-%d).json
 
# Safety: uses a separate CVE database (free tier available)
pip install safety
safety check
safety check --output json > ~/safety_pre_$(date +%Y-%m-%d).json



Step 9: Classify all updates by risk tier


Semver format is MAJOR.MINOR.PATCH. Use this script to automatically classify every outdated package before running a single install command. Patch updates are safe to batch. Minor and major updates require individual attention.


# Save this as classify.py and run it
python3 -c "
import subprocess, json
 
result = subprocess.run(
    ['pip', 'list', '--outdated', '--format=json'],
    capture_output=True, text=True
)
packages = json.loads(result.stdout)
 
patch, minor, major = [], [], []
for p in packages:
    cur = p['version'].split('.')
    lat = p['latest_version'].split('.')
    name = p['name']
    row = f\"{name} {p['version']} -> {p['latest_version']}\"
    if cur[0] != lat[0]:
        major.append(row)
    elif cur[1] != lat[1]:
        minor.append(row)
    else:
        patch.append(row)
 
print('\n[PATCH — safe to batch update]')
for r in patch: print(' +', r)
print('\n[MINOR — read changelogs first]')
for r in minor: print(' ~', r)
print('\n[MAJOR — read migration guide, update last]')
for r in major: print(' !', r)
"



Step 10: Tier 1 — Update CVE-flagged packages individually


Any package flagged by pip-audit gets updated first, one at a time, before anything else. After each update, re-run the security scan to confirm the vulnerability is resolved.


# Update each CVE-flagged package individually
# Replace with the package names from your pip-audit output
pip install --upgrade cryptography
pip show cryptography | grep Version
 
pip install --upgrade Pillow
pip show Pillow | grep Version
 
# Re-run CVE scan immediately after each fix
pip-audit
safety check
 
# Expected output after all CVE packages are patched:
# No known vulnerabilities found



Step 11: Tier 1 continued — Batch update all patch-level packages


Patch updates (x.y.Z — same major, same minor) are safe to apply as a batch. This script skips Django (handled separately) and only touches packages where the major and minor version are unchanged.


python3 -c "
import subprocess, json
 
result = subprocess.run(
    ['pip', 'list', '--outdated', '--format=json'],
    capture_output=True, text=True
)
pkgs = json.loads(result.stdout)
 
for p in pkgs:
    cur = p['version'].split('.')
    lat = p['latest_version'].split('.')
    name = p['name']
    if name.lower() == 'django':
        continue  # skip Django — handled separately
    # Only patch updates: same major AND same minor version
    if cur[0] == lat[0] and cur[1] == lat[1]:
        print(f'Updating patch: {name} {p[\"version\"]} -> {p[\"latest_version\"]}')
        subprocess.run(['pip', 'install', '--upgrade', name])
"
 
# Verify each critical package imports correctly after batch update
python3 -c "import cryptography; print('cryptography OK', cryptography.__version__)"
python3 -c "import PIL; print('Pillow OK', PIL.__version__)"
python3 -c "import requests; print('requests OK', requests.__version__)"
 
# Run Django system check after patch updates
python manage.py check



Step 12: Tier 2 — Update minor-version packages by group


Minor updates may deprecate old APIs or change behaviour. Update in functional groups so that a failure is isolated to one area. Run manage.py check after each group before proceeding to the next.


# Check the changelog for each package before updating
# pip show gives you the homepage URL
pip show gunicorn
pip show psycopg2-binary
pip show whitenoise
 
# Group 1: Database drivers
pip install --upgrade psycopg2-binary
python3 -c "import psycopg2; print('psycopg2 OK', psycopg2.__version__)"
python manage.py check
 
# Group 2: Web server and static file serving
pip install --upgrade gunicorn whitenoise
python3 -c "import gunicorn; print('gunicorn OK', gunicorn.__version__)"
python manage.py check
 
# Group 3: Utility libraries
pip install --upgrade requests certifi urllib3
python3 -c "import requests; print('requests OK', requests.__version__)"
python manage.py check
 
# Group 4: All remaining minor-version updates (same major, skip Django)
python3 -c "
import subprocess, json
result = subprocess.run(['pip','list','--outdated','--format=json'],
    capture_output=True, text=True)
pkgs = json.loads(result.stdout)
for p in pkgs:
    cur = p['version'].split('.')
    lat = p['latest_version'].split('.')
    name = p['name']
    if name.lower() == 'django':
        continue
    if cur[0] == lat[0] and cur[1] != lat[1]:
        print(f'Updating minor: {name} {p[\"version\"]} -> {p[\"latest_version\"]}')
        subprocess.run(['pip', 'install', '--upgrade', name])
"
python manage.py check



Step 13: Update Django itself


Django updates touch your entire application. Even a minor version bump (4.1 → 4.2) can deprecate patterns you rely on. Always read the release notes at docs.djangoproject.com/en/stable/releases/ before running any of the commands below.


# Check current and latest available Django version
python -c "import django; print(django.__version__)"
pip index versions Django
 
# --- Scenario A: Patch update (e.g. 4.2.8 -> 4.2.11) ---
# Safe to update directly within the same minor series
pip install "Django>=4.2,<4.3"
python -c "import django; print(django.__version__)"
 
# --- Scenario B: Minor update (e.g. 4.1.x -> 4.2.x) ---
pip install "Django>=4.2,<5.0"
 
# Check for deprecation warnings introduced by the new version
python -W all manage.py check 2>&1 | grep -i "warning\|deprecated"
 
# Run system checks
python manage.py check
python manage.py check --deploy
 
# Check for new required migrations
python manage.py migrate --check
python manage.py showmigrations
 
# --- Scenario C: Major update (e.g. 4.x -> 5.x) ---
pip install "Django>=5.0,<6.0"
 
# Scan your codebase for removed APIs
# url() was removed in Django 4.0 — replace with re_path()
grep -r "from django.conf.urls import url" . --include="*.py"
 
# MIDDLEWARE_CLASSES was removed in Django 2.0
grep -r "MIDDLEWARE_CLASSES" . --include="*.py"
 
# Run checks — expect possible errors on a major bump, fix them before restarting
python manage.py check 2>&1 | tee ~/django_check_output.txt



Step 14: Tier 3 — Update major-version packages (non-Django)


Major version bumps in third-party packages often involve breaking API changes. Update these last, one at a time, with an explicit import test and manage.py check after each one. If a package breaks the check, pin it back and defer it.


# List all major-version jumps (excluding Django)
python3 -c "
import subprocess, json
result = subprocess.run(['pip','list','--outdated','--format=json'],
    capture_output=True, text=True)
pkgs = json.loads(result.stdout)
for p in pkgs:
    cur = p['version'].split('.')[0]
    lat = p['latest_version'].split('.')[0]
    if cur != lat and p['name'].lower() != 'django':
        print(f\"{p['name']}: {p['version']} -> {p['latest_version']}  [MAJOR BUMP]\")
"
 
# Update each major package individually and test immediately after
# Example — Pillow major update
pip install --upgrade Pillow
python3 -c "
from PIL import Image
img = Image.new('RGB', (100, 100))
print('Pillow OK:', img.size)
"
python manage.py check
 
# If a major update breaks manage.py check, pin it back
pip install "Pillow



Step 15: Resolve dependency conflicts


After updating multiple packages, transitive dependencies may have conflicting version requirements. Run pip check to detect these and resolve them before proceeding.


# Detect any broken or conflicting dependencies
pip check
 
# Install pipdeptree for a full visual dependency tree
pip install pipdeptree
pipdeptree
 
# Show what depends on a specific package (reverse lookup)
pipdeptree --reverse --packages urllib3
 
# Strategy 1: Let pip resolve automatically with eager strategy
pip install --upgrade --upgrade-strategy eager requests botocore
 
# Strategy 2: Pin manually to a compatible range
pip install "urllib3>=1.25.4,<2"
 
# Strategy 3: Use pip-compile for full deterministic resolution
pip install pip-tools
pip-compile requirements.in   # generates a fully resolved requirements.txt
pip-sync requirements.txt     # installs exactly those versions
 
# Confirm no broken requirements remain
pip check
# Expected output: No broken requirements



Step 16: Run the full Django check and test suite


This must pass completely before restarting any services. Do not proceed to the deployment steps if any check returns errors or any test fails.


# Basic system check
python manage.py check
 
# Production deployment check (SSL, HSTS, secure cookies, debug mode)
python manage.py check --deploy
 
# Check for deprecation warnings
python -W all manage.py check 2>&1 | grep -i warning
 
# Check for unapplied migrations
python manage.py migrate --check
python manage.py showmigrations
 
# Dry-run collectstatic to catch any static file errors
python manage.py collectstatic --noinput --dry-run
 
# Run the full test suite
python manage.py test --verbosity=2
 
# Or if using pytest-django
pytest --tb=short -v



Step 17: Apply database migrations


If Django or any app introduced new migrations, apply them now — before restarting gunicorn. A Django minor update often includes internal migrations for built-in apps such as auth or contenttypes.


# Show full migration status
python manage.py showmigrations
 
# Apply all pending migrations
python manage.py migrate
 
# Confirm all migrations are applied — unapplied ones show as [ ]
python manage.py showmigrations | grep "\[ \]"
# This should return no output if everything is applied



Step 18: Collect static files


Django updates often ship new admin CSS and JavaScript files. Collecting static files ensures the updated assets are served correctly.


# Collect static files to STATIC_ROOT
python manage.py collectstatic --noinput
 
# Verify Django admin static files are present
# (Django updates often include new admin CSS/JS)
ls /path/to/your/staticroot/admin/css/ | head -5



Step 19: Reload gunicorn and nginx gracefully


Use systemctl reload rather than restart. This sends a SIGHUP to the gunicorn master process, which replaces workers gracefully while finishing any in-flight requests — zero downtime.


# Method 1: systemctl reload (preferred)
sudo systemctl reload gunicorn
 
# Method 2: send SIGHUP directly to the master process
sudo kill -HUP $(cat /run/gunicorn/gunicorn.pid)
 
# Method 3: full restart if reload is not configured (brief ~1s downtime)
sudo systemctl restart gunicorn
 
# Confirm gunicorn is running after reload
sudo systemctl status gunicorn
ps aux | grep gunicorn
 
# Test nginx config before reloading it
sudo nginx -t
# Expected output:
# nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
# nginx: configuration file /etc/nginx/nginx.conf test is successful
 
# Reload nginx only if you changed its config
sudo systemctl reload nginx
sudo systemctl status nginx



Step 20: Validate the live site and monitor logs


After restarting services, validate the live site responds correctly and watch the logs for any runtime errors introduced by the updated packages. Monitor for at least 30 minutes before closing the tmux session.


# HTTP smoke test — expect 200
curl -I https://yourdomain.com
 
# Check Django admin redirects correctly — expect 302
curl -I https://yourdomain.com/admin/
 
# Verify security headers are present
curl -I https://yourdomain.com 2>&1 | grep -iE \
  "x-frame-options|x-content-type|strict-transport"
 
# Watch gunicorn logs in real time
sudo journalctl -u gunicorn -f --since "2 min ago"
 
# Watch nginx error log
sudo tail -f /var/log/nginx/error.log
 
# Check Django application error log
sudo tail -f /var/log/django/error.log
 
# Scan for errors and tracebacks in the last 30 minutes
sudo journalctl -u gunicorn --since "30 min ago" | grep -iE "error|traceback|exception"



Step 21: Run post-update CVE scan


Run the security scan again on the fully updated environment and compare it against the pre-update scan from Step 8 to confirm all vulnerabilities were resolved.


# Final security scan on the updated environment
pip-audit
safety check
 
# Save post-update scan for comparison
pip-audit --format json > ~/cve_post_$(date +%Y-%m-%d).json
 
# Diff pre vs post to confirm vulnerabilities were resolved
diff ~/cve_pre_*.json ~/cve_post_*.json



Step 22: Pin and commit the final requirements


Freeze the exact updated versions and commit them to your repository with a descriptive message documenting what changed and which CVEs were patched.


# Freeze the final exact pinned versions
pip freeze > requirements.txt
 
# Review the diff vs what you started with
diff requirements.backup.*.txt requirements.txt
 
# Commit to git with a descriptive message
git add requirements.txt
git commit -m "chore(deps): update all dependencies $(date +%Y-%m-%d)
 
- Django: X.Y.Z -> A.B.C
- cryptography: patched CVE-XXXX-XXXXX
- Pillow: patched CVE-XXXX-XXXXX
- All patch and minor updates applied
- Full test suite passing
- No broken requirements (pip check clean)
- pip-audit: no known vulnerabilities"
 
git push
 
# Close the tmux session — upgrade is complete
# Ctrl+B D to detach safely, then:
tmux kill-session -t django_upgrade



Rollback — Path A: Dependencies only (no migration changes)


If something breaks and no new migrations were applied, reinstall from the frozen backup taken in Step 4.


# Reinstall exact pre-upgrade versions from the frozen backup
pip install -r requirements.backup.YYYY-MM-DD_HHMM.txt
 
# Verify Django version reverted
python -c "import django; print(django.__version__)"
python manage.py check
 
# Reload gunicorn
sudo systemctl reload gunicorn
 
# Smoke test
curl -I https://yourdomain.com



Rollback — Path B: Reverse migrations then rollback dependencies


If migrations were applied during the upgrade, reverse them first before reinstalling the old packages.


# Show current migration state to identify what was applied
python manage.py showmigrations
 
# Reverse to the last migration before the upgrade
# Replace app_name and migration_name with the previous state
python manage.py migrate app_name 0023_previous_migration_name
 
# For Django built-in apps if needed
python manage.py migrate auth 0012_alter_user_first_name_max_length
 
# Rollback dependencies
pip install -r requirements.backup.YYYY-MM-DD_HHMM.txt
 
# Verify and reload
python manage.py check
sudo systemctl reload gunicorn



Rollback — Path C: Full database restore


If the database is in an inconsistent state, restore from the backup taken in Step 5. For a complete server rollback, use the VPS snapshot from your provider's control panel — it restores the entire server in 3–10 minutes without changing your IP address.


# PostgreSQL restore from custom-format dump
pg_restore -U your_db_user -d your_db_name --clean \
  ~/backups/db_YYYY-MM-DD_HHMM.dump
 
# Or restore from plain SQL dump
psql -U your_db_user -d your_db_name < ~/backups/db_YYYY-MM-DD_HHMM.sql
 
# SQLite restore
cp ~/backups/db_YYYY-MM-DD_HHMM.sqlite3 /path/to/db.sqlite3
 
# Then rollback dependencies (Path A)
pip install -r requirements.backup.YYYY-MM-DD_HHMM.txt
sudo systemctl restart gunicorn
 
# Run pip-audit on the restored environment to confirm security posture
pip-audit