App Deployment·8 min read·

Python Deploy Script: git pull, pip install, migrate

Write a small deploy script that git pulls, installs pinned deps, runs migrations, and restarts systemd so Python releases stay boring and repeatable.

NB

Netbay Engineering

Netbay Engineering

On this page

A production Python app should update in one command you could run while half awake. git pull, install the lock file, collectstatic, migrate, restart the units. That is the whole pipeline on a single Ubuntu VPS. Fancy CI can call the same script later. If the steps only exist in a chat log, you will skip migrate on a Friday and spend Saturday repairing it.

The script runs on the box, as root or as a deploy user with sudo rights to the app units. The app code stays owned by app. Secrets stay in .env and are never rewritten by git.

The Script, in Order, With Fail-Fast

set -euo pipefail is not optional. A failed pip install must not restart the old code against new migrations, and a failed migrate must not restart at all.

bash
#!/bin/bash
set -euo pipefail
APP=/srv/app
VENV=$APP/.venv/bin
UNIT=app
WORKER=app-worker

cd $APP
sudo -u app git fetch --prune origin
sudo -u app git reset --hard origin/main

sudo -u app $VENV/pip install -r $APP/requirements.lock
sudo -u app $VENV/python $APP/manage.py collectstatic --noinput
sudo -u app $VENV/python $APP/manage.py migrate --noinput

sudo systemctl restart $UNIT
if systemctl list-units --full -all | grep -q $WORKER; then
  sudo systemctl restart $WORKER
fi

sudo systemctl is-active --quiet $UNIT
curl -fsS http://127.0.0.1:8000/healthz >/dev/null
echo deploy ok

git reset --hard origin/main makes the working tree match the remote. That is what you want on a VPS: no local commits, no dirty collectstatic leftovers. If you must keep a local .env, it is already untracked. Do not git clean -fdx without an exclude; that deletes .env and media.

pip install -r requirements.lock, not pip install -e . and not a floating requirements.txt. The lock is the artifact you tested. migrate --noinput applies pending migrations. Run it before restart so new workers never boot against an old schema, and old workers never run new code against a half-migrated schema for long. The gap is still there: during migrate, old workers are live. Keep migrations additive (add column nullable, then backfill, then constrain) so old code survives the new schema for one deploy.

The health check after restart is the difference between a script and hope. curl -fsS fails the script if Gunicorn did not come back. systemctl is-active --quiet is not enough; a process can be running and still 500.

Who Runs It, and From Where

A dedicated deploy user with sudo limited to systemctl restart app, systemctl restart app-worker, and maybe journalctl is cleaner than root SSH. git remote is SSH with a deploy key that can only read the repo. The VPS in Lucknow pulls; GitHub does not push onto the VPS. That direction matters for firewalls: outbound 443/22 from the box, no inbound git protocol.

Do not put DATABASE_URL on the command line. The script inherits nothing secrets-related except what systemd already has in the unit. manage.py migrate reads the same EnvironmentFile if you wrap it:

bash
sudo systemd-run --uid=app --property=EnvironmentFile=/srv/app/.env   $VENV/python $APP/manage.py migrate --noinput

systemd-run is the honest way to give a one-shot the same env as the service. Otherwise the deploy user must source .env, which is how secrets end up in bash history.

Collectstatic and migrate as user app, never as root, so file ownership stays correct. Media stays in a directory git does not touch.

Rollback Is the Same Script, Different SHA

reset --hard origin/main is forward. Rollback is git reset --hard OLDSHA then the same pip, collectstatic, migrate, restart. Backward migrations are not always possible; if you cannot reverse a data migration, rollback is a restore from backup plus the old SHA. Test that path once.

Skip restarting nginx unless its config changed. Python deploys are not TLS deploys. Skip reboot. Skip docker compose down if you are not on containers. The unit files you wrote earlier are the process manager.

Log every deploy: echo "$(date -Is) $SHA" >> /var/log/app-deploy.log. High-Speed SSD will not mind a 40-byte line. When the site breaks at 18:00, you want to know which SHA started at 17:51.

L3/L4 DDoS filtering and Intel Xeon Platinum do not deploy your app. The script does. Keep it in the repo as deploy/prod.sh so the next person is not guessing flags.

What Not to Hide in the Script

  • Do not curl | bash a migrate helper from the internet.
  • Do not pip install --upgrade pip on every deploy unless you pinned pip too.
  • Do not run tests on the production box as a gate; run them in CI, then deploy a SHA that already passed.
  • Do not start a second copy of Gunicorn on port 8001 and call it blue-green unless you also taught nginx to flip. One box, one bind, restart is enough.
Deploy sequence on one Ubuntu VPS git fetch reset --hard pip install from lock file collectstatic then migrate systemctl restart web then worker curl /healthz fail the script if this fails rollback = old SHA + same steps additive migrations preferred

Takeaway

One bash script, fail-fast, lock file, migrate before restart, health check after. git on the VPS pulls a SHA you already trust. That is a deploy. A checklist in someone's head is not.

You can spin up an Ubuntu 24.04 instance on Netbay in under 60 seconds and run this script on the first release — netbayhosts.in.

Keep reading

Follow along on a real VPS

Deploy Linux in under 60 seconds

These guides are written against Ubuntu, Debian, and RHEL-family images — the same ones on NetBay.

Deploy an instance