Deploy Django With git push and a post-receive Hook

Deploy Django to your own server with git push production main: a bare repository with a post-receive hook that checks out the code, installs dependencies, runs migrations and collectstatic, and reloads Gunicorn, with safe failure handling.

Problem statement

Heroku made git push heroku main the simplest deploy experience. On your own VPS, you can get the same workflow without a platform: a bare Git repository on the server receives your push, and a post-receive hook builds and releases the code. It is ideal for solo developers and small teams who want one-command deploys without setting up a CI service, as long as the hook is written carefully so a failed build never breaks the live site.

git push deploysgit pushto server remoteBare reporeceives commitspost-receive hookbuild new releaseSwitch + reloadGunicorn HUPThe hook runs the deploy on the server every time you push to the production branch.
  1. On the server, create a bare repo: git init --bare /srv/git/myapp.git owned by the deploy user.
  2. Add a hooks/post-receive script that checks out the pushed branch into a new release directory.
  3. In the hook: create or reuse the venv, pip install, check --deploy, migrate, collectstatic, switch the current symlink, systemctl reload gunicorn.
  4. Allow the deploy user to reload services with a narrow sudoers rule.
  5. Locally: git remote add production deploy@server:/srv/git/myapp.git, then git push production main.
  6. Run tests before pushing (locally or in CI); the hook is not a test runner.

Step-by-step solution

1. Create the bare repository

sudo -u deploy mkdir -p /srv/git
sudo -u deploy git init --bare /srv/git/myapp.git

A bare repository has no working tree, only Git data, so pushing to it never conflicts with checked-out files.

2. Write the hook

#!/usr/bin/env bash
# /srv/git/myapp.git/hooks/post-receive
set -euo pipefail
APP=/srv/app
DEPLOY_BRANCH=main

while read -r oldrev newrev ref; do
  [ "$ref" = "refs/heads/$DEPLOY_BRANCH" ] || { echo "Ignoring $ref"; continue; }

  TS=$(date -u +%Y%m%dT%H%M%S)
  REL=$APP/releases/$TS
  echo "==> Building $newrev in $REL"
  mkdir -p "$REL"
  git --work-tree="$REL" --git-dir="$PWD" checkout -f "$newrev"

  python3 -m venv "$REL/.venv"
  "$REL/.venv/bin/pip" install -q -r "$REL/requirements.txt"
  ln -sfn $APP/shared/media "$REL/media"

  set -a; . $APP/shared/env; set +a
  cd "$REL"
  .venv/bin/python manage.py check --deploy
  .venv/bin/python manage.py migrate --noinput
  .venv/bin/python manage.py collectstatic --noinput -v 0

  ln -sfn "$REL" $APP/current.tmp && mv -Tf $APP/current.tmp $APP/current
  sudo /bin/systemctl reload gunicorn
  sudo /bin/systemctl restart celery-worker
  echo "$newrev" > "$REL/REVISION"
  echo "==> Deployed $newrev"
done
chmod +x /srv/git/myapp.git/hooks/post-receive

The hook's output streams back to your terminal, so you see the deploy as it happens. With set -e, any failing command stops the hook before the symlink switch; the live release stays as it was.

inside post-receive1Ignore other branchesonly main deploys2Checkout into releases/<ts>git --work-tree3venv, pip, check --deployfail fast, live site untouched4migrate, collectstaticbackward-compatible migrations5Switch current, reloadGunicorn HUP, Celery restartEvery step before the switch can fail safelydjango-deployment.com
Building into a new directory is what makes a failed push harmless.

3. Allow service reloads without full sudo

# /etc/sudoers.d/deploy  (edit with visudo -f)
deploy ALL=(root) NOPASSWD: /bin/systemctl reload gunicorn, /bin/systemctl restart celery-worker

Only these exact commands are allowed without a password.

4. Push to deploy

git remote add production deploy@203.0.113.10:/srv/git/myapp.git
git push production main

You see the hook's output, ending in Deployed <sha>. To redeploy the same commit, push an empty commit or run the hook manually with the revision.

5. Keep tests in front of the push

The hook should build and release, not decide whether the code is good. Run the test suite locally or require CI to pass before merging to main, then push. For teams, a CI job that pushes to the server remote after tests pass gives the same flow with an automatic gate.

who should trigger the deploy?How many people deploy, and how?solo developergit push productiontests run locally firstsmall teamCI pushes after testssame hook, automatic gatemany servicesFull CI/CD or a PaaSimages, approvalsThe same hook works whether a person or CI does the pushdjango-deployment.com
Start with direct pushes and add a CI gate when more people deploy.

Verification check: git push production main prints the hook output and ends with the deployed SHA; cat /srv/app/current/REVISION matches git rev-parse main; pushing a commit with a deliberate error stops at check --deploy and leaves the site serving the previous release.

Explanation

Git runs hooks at defined points; post-receive runs on the server after a push has been accepted, receiving the old and new revisions on standard input. A bare repository plus git --work-tree=... checkout exports a specific commit into any directory without a persistent working copy. Combining that with release directories gives atomic switches and rollbacks, and the push itself becomes the deploy trigger. This is the same mechanism Dokku and early Heroku used, without the buildpack layer.

Edge cases / notes

  • Long installs keep your terminal busy; reuse a wheel cache or copy the previous venv when requirements are unchanged.
  • Force pushes deploy whatever commit you push; protect the production branch locally or deploy only tags.
  • Multiple servers need the hook on each or a central CI step; git push deploys suit single servers best.
  • Secrets stay in /srv/app/shared/env on the server and never in the repository.

Next steps

FAQ

How do I deploy Django with git push?

Create a bare repository on the server with a post-receive hook that builds a release, migrates, and reloads Gunicorn.

What happens if the deploy fails halfway?

With release directories and set -e, the hook stops before switching current, so the live site keeps running the old release.

Can the hook run tests?

It can, but it slows every push. Run tests locally or in CI before pushing.

Do I need root for the hook?

No. The deploy user runs it, with a narrow sudoers rule for reloading services.

Keep going